All files / controllers window-controller.ts

100% Statements 140/140
100% Branches 68/68
100% Functions 32/32
100% Lines 133/133
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449                                    5x 5x 5x 5x 5x 5x 5x 5x       5x         53x 53x 53x   53x 53x 53x               58x           53x           53x           53x           53x 53x             53x 53x 53x     53x                       13x   13x 1x         12x 12x                     17x 17x 2x     15x     15x 11x   4x   3x         1x     4x 1x     3x 1x     2x 1x             15x                 5x 5x 1x     4x 4x 4x 2x 2x 1x       1x                 4x     4x   3x                       5x 3x 1x     2x 1x     1x                 5x 5x 1x     4x 1x         3x   3x 1x         2x                         5x 3x                         5x 1x                       7x   7x   7x 7x   1x 1x       6x 2x 2x     4x 1x 1x     3x 4x 1x 3x 2x     1x   3x   3x                   8x 8x 1x         7x   7x         2x               5x 1x         1x   1x                   5x 2x 1x     1x                 5x       3x 2x 1x     1x                           58x 58x 2x     56x     5x   2x     3x 3x       2x 2x 1x   2x     1x                       5x 12x                 5x  
/**
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
'use strict';
 
import { FirebaseMessaging } from '@firebase/messaging-types';
import ControllerInterface from './controller-interface';
import Errors from '../models/errors';
import WorkerPageMessage from '../models/worker-page-message';
import DefaultSW from '../models/default-sw';
import NOTIFICATION_PERMISSION from '../models/notification-permission';
import FCMDetails from '../models/fcm-details';
import base64ToArrayBuffer from '../helpers/base64-to-array-buffer';
import { createSubscribe } from '@firebase/util';
 
declare const firebase: any;
 
export default class WindowController extends ControllerInterface
  implements FirebaseMessaging {
  private registrationToUse_;
  private publicVapidKeyToUse_;
  private manifestCheckPromise_;
  private messageObserver_ = null;
  private onMessage_ = createSubscribe(observer => {
    this.messageObserver_ = observer;
  });
  private tokenRefreshObserver_ = null;
  private onTokenRefresh_ = createSubscribe(observer => {
    this.tokenRefreshObserver_ = observer;
  });
 
  /**
   * A service that provides a MessagingService instance.
   * @param {!firebase.app.App} app
   */
  constructor(app) {
    super(app);
 
    /**
     * @private
     * @type {ServiceWorkerRegistration}
     */
    this.registrationToUse_;
 
    /**
     * @private
     * @type {Promise}
     */
    this.manifestCheckPromise_;
 
    /**
     * @private
     * @type {firebase.Observer}
     */
    this.messageObserver_ = null;
 
    /**
     * @private {!firebase.Subscribe} The subscribe function to the onMessage
     * observer.
     */
    this.onMessage_ = createSubscribe(observer => {
      this.messageObserver_ = observer;
    });
 
    /**
     * @private
     * @type {firebase.Observer}
     */
    this.tokenRefreshObserver_ = null;
    this.onTokenRefresh_ = createSubscribe(observer => {
      this.tokenRefreshObserver_ = observer;
    });
 
    this.setupSWMessageListener_();
  }
 
  /**
   * This method returns an FCM token if it can be generated.
   * The return promise will reject if the browser doesn't support
   * FCM, if permission is denied for notifications or it's not
   * possible to generate a token.
   * @export
   * @return {Promise<string> | Promise<null>} Returns a promise the
   * resolves to an FCM token or null if permission isn't granted.
   */
  getToken() {
    // Check that the required API's are available
    if (!this.isSupported_()) {
      return Promise.reject(
        this.errorFactory_.create(Errors.codes.UNSUPPORTED_BROWSER)
      );
    }
 
    return this.manifestCheck_().then(() => {
      return super.getToken();
    });
  }
 
  /**
   * The method checks that a manifest is defined and has the correct GCM
   * sender ID.
   * @private
   * @return {Promise} Returns a promise that resolves if the manifest matches
   * our required sender ID
   */
  manifestCheck_() {
    if (this.manifestCheckPromise_) {
      return this.manifestCheckPromise_;
    }
 
    const manifestTag = <HTMLAnchorElement>document.querySelector(
      'link[rel="manifest"]'
    );
    if (!manifestTag) {
      this.manifestCheckPromise_ = Promise.resolve();
    } else {
      this.manifestCheckPromise_ = fetch(manifestTag.href)
        .then(response => {
          return response.json();
        })
        .catch(() => {
          // If the download or parsing fails allow check.
          // We only want to error if we KNOW that the gcm_sender_id is incorrect.
          return Promise.resolve();
        })
        .then(manifestContent => {
          if (!manifestContent) {
            return;
          }
 
          if (!manifestContent['gcm_sender_id']) {
            return;
          }
 
          if (manifestContent['gcm_sender_id'] !== '103953800507') {
            throw this.errorFactory_.create(
              Errors.codes.INCORRECT_GCM_SENDER_ID
            );
          }
        });
    }
 
    return this.manifestCheckPromise_;
  }
 
  /**
   * Request permission if it is not currently granted
   * @export
   * @returns {Promise} Resolves if the permission was granted, otherwise
   * rejects
   */
  requestPermission() {
    if ((Notification as any).permission === NOTIFICATION_PERMISSION.granted) {
      return Promise.resolve();
    }
 
    return new Promise((resolve, reject) => {
      const managePermissionResult = result => {
        if (result === NOTIFICATION_PERMISSION.granted) {
          return resolve();
        } else if (result === NOTIFICATION_PERMISSION.denied) {
          return reject(
            this.errorFactory_.create(Errors.codes.PERMISSION_BLOCKED)
          );
        } else {
          return reject(
            this.errorFactory_.create(Errors.codes.PERMISSION_DEFAULT)
          );
        }
      };
 
      // The Notification.requestPermission API was changed to
      // return a promise so now have to handle both in case
      // browsers stop support callbacks for promised version
      const permissionPromise = Notification.requestPermission(
        managePermissionResult
      );
      if (permissionPromise) {
        // Prefer the promise version as it's the future API.
        permissionPromise.then(managePermissionResult);
      }
    });
  }
 
  /**
   * This method allows a developer to override the default service worker and
   * instead use a custom service worker.
   * @export
   * @param {!ServiceWorkerRegistration} registration The service worker
   * registration that should be used to receive the push messages.
   */
  useServiceWorker(registration) {
    if (!(registration instanceof ServiceWorkerRegistration)) {
      throw this.errorFactory_.create(Errors.codes.SW_REGISTRATION_EXPECTED);
    }
 
    if (typeof this.registrationToUse_ !== 'undefined') {
      throw this.errorFactory_.create(Errors.codes.USE_SW_BEFORE_GET_TOKEN);
    }
 
    this.registrationToUse_ = registration;
  }
 
  /**
   * This method allows a developer to override the default vapid key
   * and instead use a custom VAPID public key.
   * @export
   * @param {!string} publicKey A URL safe base64 encoded string.
   */
  usePublicVapidKey(publicKey) {
    if (typeof publicKey !== 'string') {
      throw this.errorFactory_.create(Errors.codes.INVALID_PUBLIC_VAPID_KEY);
    }
 
    if (typeof this.publicVapidKeyToUse_ !== 'undefined') {
      throw this.errorFactory_.create(
        Errors.codes.USE_PUBLIC_KEY_BEFORE_GET_TOKEN
      );
    }
 
    const parsedKey = base64ToArrayBuffer(publicKey);
 
    if (parsedKey.length !== 65) {
      throw this.errorFactory_.create(
        Errors.codes.PUBLIC_KEY_DECRYPTION_FAILED
      );
    }
 
    this.publicVapidKeyToUse_ = parsedKey;
  }
 
  /**
   * @export
   * @param {!firebase.Observer|function(*)} nextOrObserver An observer object
   * or a function triggered on message.
   * @param {function(!Error)=} optError Optional A function triggered on
   * message error.
   * @param {function()=} optCompleted Optional function triggered when the
   * observer is removed.
   * @return {!function()} The unsubscribe function for the observer.
   */
  onMessage(nextOrObserver, optError?, optCompleted?) {
    return this.onMessage_(nextOrObserver, optError, optCompleted);
  }
 
  /**
   * @export
   * @param {!firebase.Observer|function()} nextOrObserver An observer object
   * or a function triggered on token refresh.
   * @param {function(!Error)=} optError Optional A function
   * triggered on token refresh error.
   * @param {function()=} optCompleted Optional function triggered when the
   * observer is removed.
   * @return {!function()} The unsubscribe function for the observer.
   */
  onTokenRefresh(nextOrObserver, optError, optCompleted) {
    return this.onTokenRefresh_(nextOrObserver, optError, optCompleted);
  }
 
  /**
   * Given a registration, wait for the service worker it relates to
   * become activer
   * @private
   * @param  {ServiceWorkerRegistration} registration Registration to wait
   * for service worker to become active
   * @return {Promise<!ServiceWorkerRegistration>} Wait for service worker
   * registration to become active
   */
  waitForRegistrationToActivate_(registration) {
    const serviceWorker =
      registration.installing || registration.waiting || registration.active;
 
    return new Promise<ServiceWorkerRegistration>((resolve, reject) => {
      if (!serviceWorker) {
        // This is a rare scenario but has occured in firefox
        reject(this.errorFactory_.create(Errors.codes.NO_SW_IN_REG));
        return;
      }
      // Because the Promise function is called on next tick there is a
      // small chance that the worker became active or redundant already.
      if (serviceWorker.state === 'activated') {
        resolve(registration);
        return;
      }
 
      if (serviceWorker.state === 'redundant') {
        reject(this.errorFactory_.create(Errors.codes.SW_REG_REDUNDANT));
        return;
      }
 
      let stateChangeListener = () => {
        if (serviceWorker.state === 'activated') {
          resolve(registration);
        } else if (serviceWorker.state === 'redundant') {
          reject(this.errorFactory_.create(Errors.codes.SW_REG_REDUNDANT));
        } else {
          // Return early and wait to next state change
          return;
        }
        serviceWorker.removeEventListener('statechange', stateChangeListener);
      };
      serviceWorker.addEventListener('statechange', stateChangeListener);
    });
  }
 
  /**
   * This will regiater the default service worker and return the registration
   * @private
   * @return {Promise<!ServiceWorkerRegistration>} The service worker
   * registration to be used for the push service.
   */
  getSWRegistration_() {
    if (this.registrationToUse_) {
      return this.waitForRegistrationToActivate_(this.registrationToUse_);
    }
 
    // Make the registration null so we know useServiceWorker will not
    // use a new service worker as registrationToUse_ is no longer undefined
    this.registrationToUse_ = null;
 
    return navigator.serviceWorker
      .register(DefaultSW.path, {
        scope: DefaultSW.scope
      })
      .catch(err => {
        throw this.errorFactory_.create(
          Errors.codes.FAILED_DEFAULT_REGISTRATION,
          {
            browserErrorMessage: err.message
          }
        );
      })
      .then(registration => {
        return this.waitForRegistrationToActivate_(registration).then(() => {
          this.registrationToUse_ = registration;
 
          // We update after activation due to an issue with Firefox v49 where
          // a race condition occassionally causes the service work to not
          // install
          registration.update();
 
          return registration;
        });
      });
  }
 
  /**
   * This will return the default VAPID key or the uint8array version of the public VAPID key
   * provided by the developer.
   * @private
   */
  getPublicVapidKey_(): Promise<Uint8Array> {
    if (this.publicVapidKeyToUse_) {
      return Promise.resolve(this.publicVapidKeyToUse_);
    }
 
    return Promise.resolve(FCMDetails.DEFAULT_PUBLIC_VAPID_KEY);
  }
 
  /**
   * Gets a PushSubscription for the current user.
   * @private
   * @param {ServiceWorkerRegistration} registration
   * @return {Promise<PushSubscription>}
   */
  getPushSubscription_(swRegistration, publicVapidKey) {
    // Check for existing subscription first
    let subscription;
    let fcmTokenDetails;
    return swRegistration.pushManager.getSubscription().then(subscription => {
      if (subscription) {
        return subscription;
      }
 
      return swRegistration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: publicVapidKey
      });
    });
  }
 
  /**
   * This method will set up a message listener to handle
   * events from the service worker that should trigger
   * events in the page.
   *
   * @private
   */
  setupSWMessageListener_() {
    if (!('serviceWorker' in navigator)) {
      return;
    }
 
    navigator.serviceWorker.addEventListener(
      'message',
      event => {
        if (!event.data || !event.data[WorkerPageMessage.PARAMS.TYPE_OF_MSG]) {
          // Not a message from FCM
          return;
        }
 
        const workerPageMessage = event.data;
        switch (workerPageMessage[WorkerPageMessage.PARAMS.TYPE_OF_MSG]) {
          case WorkerPageMessage.TYPES_OF_MSG.PUSH_MSG_RECEIVED:
          case WorkerPageMessage.TYPES_OF_MSG.NOTIFICATION_CLICKED:
            const pushMessage =
              workerPageMessage[WorkerPageMessage.PARAMS.DATA];
            if (this.messageObserver_) {
              this.messageObserver_.next(pushMessage);
            }
            break;
          default:
            // Noop.
            break;
        }
      },
      false
    );
  }
 
  /**
   * Checks to see if the required API's are valid or not.
   * @private
   * @return {boolean} Returns true if the desired APIs are available.
   */
  isSupported_() {
    return (
      'serviceWorker' in navigator &&
      'PushManager' in window &&
      'Notification' in window &&
      'fetch' in window &&
      ServiceWorkerRegistration.prototype.hasOwnProperty('showNotification') &&
      PushSubscription.prototype.hasOwnProperty('getKey')
    );
  }
}